Skip to content

profile: Implement profile screen for users #287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Sep 7, 2023

Conversation

sirpengi
Copy link
Contributor

@sirpengi sirpengi commented Aug 25, 2023

Added profile screen with user information and custom profile fields, linked from sender name and avatar in message list.

Support in initial_snapshot and models for the related RealmDefaultExternalAccounts and CustomUserProfileFields also added, as event handling for custom_profile_fields.

User presence (#196) and user status (#197) are not yet displayed or tracked here.

new_profile_page

Fixes: #195

@sirpengi sirpengi requested review from gnprice and chrisbobbe August 25, 2023 14:02
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @sirpengi! This will be great to have.

There's a lot happening in this PR, and correspondingly a lot of comments below. I haven't fully reviewed the widgets changes or the corresponding tests, figuring that this is plenty to discuss for one round.

One thing that will definitely help with both reviewing and merging is to split this into a number of smaller commits on logical boundaries. Some specific suggestions in the comments below, but broadly I think the sequence would look like:

  • API pure refactors, like moving code around.
  • API changes, along with corresponding changes in model code. These would be several commits: one for the UserRole enum, one for realmDefaultExternalAccounts, one for CustomProfileFieldsEvent, maybe one I'm forgetting.
  • Add the timezone dependency. The line in main.dart to set it up probably goes in this commit too, on the theory that the dependency is in an unusable state before that's added.
  • Add the new screen, and the ways to navigate to it.

pubspec.yaml Outdated
@@ -57,6 +57,7 @@ dependencies:
package_info_plus: ^4.0.1
collection: ^1.17.2
url_launcher: ^6.1.11
timezone: ^0.9.2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add the dependency as a separate small prep commit. See db44a9b for a recent example.

Comment on lines 145 to 146
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) =>
_$CustomProfileFieldsEventFromJson(json);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) =>
_$CustomProfileFieldsEventFromJson(json);
factory CustomProfileFieldsEvent.fromJson(Map<String, dynamic> json) =>
_$CustomProfileFieldsEventFromJson(json);

/// For docs, search for "custom_profile_fields:"
/// in <https://zulip.com/api/register-queue>.
@JsonSerializable(fieldRename: FieldRename.snake)
class CustomProfileField {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for moving this out of model.dart and into initial_snapshot.dart?

My inclination would be to keep it in model.dart. As this PR shows, this type appears in events as well as in the initial snapshot. I think of this api/model/model.dart as the place for types that appear across multiple areas of the API, whereas those other two api/model/ files events.dart and initial_snapshot.dart are for types that appear specifically in events and in the initial snapshot respectively. (And types that are specific to other particular endpoints generally go in the respective api/route/*.dart files.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't realize there were other structures in model.dart that lived in two places (although that is clear now). I only moved it thinking that it was initially constructed in initial_snapshot.dart. Moving it back here.

@@ -120,6 +78,41 @@ class User {
Map<String, dynamic> toJson() => _$UserToJson(this);
}

/// As in [User.profileData].
@JsonSerializable(fieldRename: FieldRename.snake)
class ProfileFieldUserData {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm yeah, moving this to come after User is a good idea — the rest of this file is organized in general-to-specific order, so good to match that here.

Let's do that in its own commit, though, or I guess combined with adding that handy line of dartdoc, and perhaps combined with any other code moves of a similar nature. That commit can then also be labeled as NFC.

Comment on lines +29 to +129
@JsonKey(unknownEnumValue: UserRole.unknown)
UserRole role;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's separate out adding this enum as its own commit.

(I.e. adding the class, changing this field to use it, and other closely-connected changes like in RealmUserUpdateEvent, as one commit; but separate from adding the CustomProfileFieldsEvent type, moving the definition of ProfileFieldUserData, adding the new UI, and so on.)

style: const TextStyle(fontWeight: FontWeight.bold)),
(user.isBot ? Text(user.email) : const SizedBox.shrink()),
Text(user.role.label),
// Text("Active about XXX"), // TODO render active status
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This TODO comment can point to the relevant issue:

Suggested change
// Text("Active about XXX"), // TODO render active status
// Text("Active about XXX"), // TODO(#196) render active status

Comment on lines 224 to 229
final Widget profileDataTable;
if (user.profileData != null) {
profileDataTable = ProfileDataTable(user:user);
} else {
profileDataTable = const SizedBox.shrink();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this condition would be appropriate to push down inside ProfileDataTable.build, simplifying this code in the parent.

Comment on lines 232 to 235
final userLocalNow = getNowInTimezone(user.timezone);
if (userLocalNow != null) {
localTimeWidget = Text("${DateFormat.jm().format(userLocalNow)} Local time",
style: const TextStyle(fontWeight: FontWeight.normal));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the rest of the localTimeWidget logic can probably best be moved into a little widget of their own, as the work they're doing is pretty independent of the rest of this method.

As a bonus, that would set us up to make an enhancement where that widget would rebuild every time the answer changes (so at the start of each minute), instead of getting out of date if the screen stays open for a while and then you come back to it. That'd be neat. (Wouldn't be worth tons of effort; but my guess is that it's pretty straightforward once one lays hands on the right API.)

Comment on lines 126 to 129
test('realm_default_external_account', () {
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({});
check(realmDefaultExternalAccount)
..name.equals('site')
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like this is basically testing the fromJson method, and so checking that e.g. the name field it gets equals the value of the name property in the JSON.

That means that when it says equals('site'), the fact that 'site' is the same value as appears in the input JSON is an essential part of the test. Because it's essential to the test, it's best if that fact can be found within the test case's own source code. For example like this:

Suggested change
test('realm_default_external_account', () {
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({});
check(realmDefaultExternalAccount)
..name.equals('site')
test('realm_default_external_account', () {
final realmDefaultExternalAccount = mkRealmDefaultExternalAccount({
'name': 'site',
'hint': 'hint for site',
'text': 'description for site',
'url_pattern': 'http://example/%{username}',
});
check(realmDefaultExternalAccount)
..name.equals('site')

Where the baseJson pattern comes in handy, on the other hand (as well as other ways of expressing default/baseline test data outside an individual test), is for all the data whose specifics are irrelevant to a given test, where the test just needs something there that makes the data well-formed.


So for example up at the top of this file:

    test('delivery_email', () {
      check(mkUser({'delivery_email': 'name@email.com'}).deliveryEmailStaleDoNotUse)
        .equals('name@email.com');

that test doesn't care about any of the other properties in the input JSON, except that they have to be there and have appropriate types so that fromJson doesn't throw. So the baseJson encoded in mkUser serves it very well for those. It only cares about delivery_email — and so it specifies that one property explicitly.

We could have written that test like this:

    test('delivery_email', () {
      check(mkUser({}).deliveryEmailStaleDoNotUse)
        .equals('name@example.com');

relying on the delivery_email that's supplied in baseJson. But then the test wouldn't be self-contained — for the reader to understand what the test is trying to say, they'd need to go consult what's in baseJson. So instead we make the intended delivery_email value explicit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this particular test, it's really just testing the auto-generated code for fromJson, because we happily have no interesting customizations on this type. So what I'd therefore actually prefer to do is to leave the test out.

Where we do have customizations of our own, especially any nontrivial ones, it's good to test those specifically. That's what you see happening earlier in this file on e.g. User — we test the handling of profileData and isSystemBot, which involve code of our own. Also of deliveryEmailStaleDoNotUse, which is a bit more marginal but does have our own customization in the form of that JsonKey annotation.

@sirpengi sirpengi force-pushed the pr-profile-screen branch 3 times, most recently from 519b570 to f4648d4 Compare August 29, 2023 09:04
Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @sirpengi for the revision! Comments below.

I haven't yet read the new tests in detail, but I figured this was a good point at which there's plenty of comments for one round of review.

Comment on lines 28 to 32
final allRecipientIds = [store.account.userId, userId];
allRecipientIds.sort();
final narrow = DmNarrow(
selfUserId: store.account.userId,
allRecipientIds: allRecipientIds);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will throw when the other user is the self-user.

Probably a good way to handle this is to add another constructor on DmNarrow, something like

  factory DmNarrow.withUser(int userId, {required int selfUserId}) {
    // …

(or maybe make userId a named parameter too).

Then that can encapsulate this logic, plus the case where the user is self.

if (user.deliveryEmailStaleDoNotUse == null) {
return const SizedBox.shrink();
}
return Text(user.deliveryEmailStaleDoNotUse!);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(You asked about this in our video call, and our plan is to file a separate issue for it and leave it out of the initial version of this profile screen. Previous related discussion at #287 (comment) .)

Comment on lines 96 to 105
Widget build(BuildContext context) {
final roleLabel = switch (role) {
UserRole.owner => "Owner",
UserRole.administrator => "Administrator",
UserRole.moderator => "Moderator",
UserRole.member => "Member",
UserRole.guest => "Guest",
UserRole.unknown => "Unknown",
};
return Text(roleLabel);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine, but to be clear (and in case it feels lighter-weight to you), what I had in mind at #287 (comment) was more like

  • have a function that's just this switch statement, returning a String;
  • then ProfilePage.build can have a Text widget directly in the widget tree that it returns.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was in the mode of making everything consistently a widget, but it doesn't feel like a necessity here. I do prefer how lighter weight making this a function becomes.

lib/main.dart Outdated
@@ -13,5 +14,6 @@ void main() {
}());
LicenseRegistry.addLicense(additionalLicenses);
LiveZulipBinding.ensureInitialized();
tz.initializeTimeZones();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder how much this adds to startup time.

If it's more than like a couple of milliseconds, it may be better to defer it to be done lazily.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So I've run it a few times using the Stopwatch helper and it appears to take between 80-100 milliseconds (for comparison, LicenseRegistry.addLicense is generally 0 and LiveZulipBinding.ensureInitialized is ~2). I think we'll need to figure out a way to optimize this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(And we ended up splitting this feature out as its own issue: #292.)

@@ -78,6 +78,7 @@ class InitialSnapshot {
required this.zulipVersion,
this.zulipMergeBase,
required this.alertWords,
required this.realmDefaultExternalAccounts,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put the fields in the same order here on the constructor as their declarations

Comment on lines 255 to 258
case CustomProfileFieldType.unknown:
return Padding(
padding: const EdgeInsets.all(4),
child: Text(entry.text));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the type is one we don't know about, this will cheerfully carry on and show the raw contents (which are likely some kind of JSON blob) to the user as if the other user had typed that in as plain text. That's potentially pretty odd-looking.

Looks like the zulip-mobile implementation handles this case by skipping that custom profile field entirely, which I think is pretty reasonable. The most thorough thing would I guess be to instead explicitly say that it's there and that this app doesn't support it.

children: [
Avatar(userId: user.userId, size: 32, borderRadius: 32 / 8),
const SizedBox(width: 8),
Text(user.fullName), // TODO(#196) render active status
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a test for a user with a long name — I expect it'll throw an error for overflow in this Row.

child: Column(
children: [
const SizedBox(height: 16),
Table(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat! I don't think I've seen this widget before.

columnWidths: const {
0: FixedColumnWidth(116),
1: FlexColumnWidth()},
defaultVerticalAlignment: TableCellVerticalAlignment.top,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How does TableCellVerticalAlignment.baseline behave when one cell has multiple lines of text? If we can get basically top alignment, but with the baselines of the first lines instead of the top edges, that seems ideal.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using TableCellVerticalAlignment.baseline with TextBaseline.alphabetic still maintains top alignment, so the ideal condition has been achieved!

Table(
border: TableBorder.all(style: BorderStyle.none),
columnWidths: const {
0: FixedColumnWidth(116),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where does 116 come from?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was supposed to be based on the label width from the mobile app which was 96px for the unpadded text. I've since moved the padding around and didn't readjust the formula here (because this column needs to contain padded text). I've replaced it with 96 + 8 to match the old width with the new padding values.

@sirpengi
Copy link
Contributor Author

sirpengi commented Sep 1, 2023

@gnprice this is ready for another go! Thank you for the comments, I'm a lot happier with the state of the profile page rendering.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @sirpengi for the revision! This is definitely getting closer.

Comments below. In particular this time I went and played with the UI, which I hadn't so much in the previous rounds.

Along with the next revision, a helpful workflow step would be to include some screenshots — that's helpful especially for people following along who may not have the PR branch checked out locally. (Similarly it can also be helpful when we're looking back later, even for the author or main reviewer, to make it easy to remember just which PR did what and what the UI looked like at a given previous stage.) Probably most helpful is to edit the screenshots into the PR description, so it can't get buried in the length of the thread.

@@ -161,6 +161,13 @@ class DmNarrow extends Narrow implements SendableNarrow {
);
}

factory DmNarrow.withUser(int userId, {required int selfUserId}) {
return DmNarrow(
allRecipientIds: {userId, selfUserId}.toList()..sort(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neat. A bit tricky, though. Would definitely be good to have some tests for this — really just needs three cases, where the target user ID is equal to, less than, or greater than the self ID.

In particular I saw this code and was tempted to say it can simplify to [userId, selfUserId]..sort(), and only then remembered why we extracted it in the first place 🙂

Comment on lines 86 to 88
required this.realmDefaultExternalAccounts,
required this.userSettings,
required this.maxFileUploadSizeMib,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: match ordering of fields' declarations

Suggested change
required this.realmDefaultExternalAccounts,
required this.userSettings,
required this.maxFileUploadSizeMib,
required this.userSettings,
required this.realmDefaultExternalAccounts,
required this.maxFileUploadSizeMib,

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump

final User user;

@override
Widget build(BuildContext context) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Flutter upstream style seems to be that the build method is always at the end of the class ­— a handy convention for quickly finding it, since that's often among the first things one wants to find. So then its various helper methods go above it.

]));
}

Widget? _renderCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
Widget? _renderCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) {
Widget? _buildCustomProfileFieldValue(BuildContext context, String value, CustomProfileField realmField) {

to match the verb used for the other similar methods (and for functions returning a widget in general)

Comment on lines 243 to 244
customProfileFields.clear();
customProfileFields.addAll(_sortCustomProfileFields(event.fields));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: _sortCustomProfileFields already allocates a List, so this makes an unnecessary copy; could either assign to customProfileFields rather than clear/addAll (and make it non-final to accommodate that), or drop the toList in _sortCustomProfileFields and have the other call site do toList for itself

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've changed customProfileFields to be non-final instead. It seems this is the first non-final member, but it doesn't seem a pattern to maintain as I think there are some other top-level settings that will eventually need to be non-final (like realm_message_content_edit_limit_seconds)

Comment on lines 127 to 134
Table(
border: TableBorder.all(style: BorderStyle.none),
columnWidths: const {
0: FixedColumnWidth(96 + 8),
1: FlexColumnWidth()},
defaultVerticalAlignment: TableCellVerticalAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: entries.map((entry) => TableRow(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this Table widget seems neat and is good to be aware of, but I'm wondering now if it's actually doing much for us in this case. It looks like we should be able to get the same effect with just a Column whose children are Rows; in each row use a SizedBox(width: …, child: …) to fix the width of the label and an Expanded to let the value take up the rest of the width.

I come to that thought from looking at the FixedColumnWidth(96 + 8) and noticing that the numbers still feel a bit mysterious; and in particular that the 8 is meant to correspond to the 8 down below in the padding, but they're separated by a good few lines of code that's talking about other things. The plain Row treatment would make it, in the first instance,

          SizedBox(width: 96 + 8,
            child: Padding(padding: const EdgeInsets.fromLTRB(0, 4, 8, 4),
              child: Text(entry.label, style: const TextStyle(fontWeight: FontWeight.bold)))),

so that those numbers are right next to each other; and then at that point the idiomatic thing would be to break it up further:

          SizedBox(width: 96,
            child: Padding(padding: const EdgeInsets.symmetric(vertical: 4),
              child: Text(entry.label, style: const TextStyle(fontWeight: FontWeight.bold)))),
          const SizedBox(width: 8),

at which point I think it's a good bit clearer what's going on.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, and perhaps even a greater benefit of the plain Column/Row approach: that should let us drop all the 4px vertical padding on the individual labels and values, in favor of interspersing const SizedBox(height: 8) as children of the Column.

Comment on lines 181 to 183
final choiceItem = choiceFieldData.choices[value];
if (choiceItem == null) return null;
return _buildTextWidget(choiceItem.text);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Together with the if (choiceFieldData == null) return null; line in my suggestion above, this can be compacted to:

Suggested change
final choiceItem = choiceFieldData.choices[value];
if (choiceItem == null) return null;
return _buildTextWidget(choiceItem.text);
final choiceText = choiceFieldData?.choices[value]?.text;
return choiceText == null ? null : _buildTextWidget(choiceText);

Comment on lines 160 to 163
case CustomProfileFieldType.shortText:
case CustomProfileFieldType.longText:
case CustomProfileFieldType.pronouns:
return _buildTextWidget(value);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
case CustomProfileFieldType.shortText:
case CustomProfileFieldType.longText:
case CustomProfileFieldType.pronouns:
return _buildTextWidget(value);
case CustomProfileFieldType.shortText:
case CustomProfileFieldType.longText:
case CustomProfileFieldType.pronouns:
// The web client appears to treat `longText` identically to `shortText`;
// `pronouns` is explicitly meant to display the same as `shortText`.
return _buildTextWidget(value);

(Copying from the zulip-mobile implementation.) This comment is basically another piece of the reverse-engineering results that substitute here for API docs. Even though it doesn't involve any arbitrary names (like choices, text, subtype, etc., as seen for some of the other CustomProfileFieldType values), it's information about the semantics of these types which wouldn't be obvious a priori.


Widget _buildTextWidget(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels vertically pretty cramped to me:
image

Compare zulip-mobile:
image

(The difference in URL text is due to the bug I mentioned in a comment above where the arguments to _buildLinkWidget are swapped.)

I see that the source code in both implementations appears to call for the same 8px of spacing in between, though. Hmm — I suspect this TextStyle.height setting is the issue:

      style: const TextStyle(fontSize: 15, height: 1),

Aha, yep, if I remove both that and the height: 1.5 elsewhere, it gets more spacious. I think a typical TextStyle.height is more like 1.2, not 1.0.

The best solution here may be to just make less use of DefaultTextStyle — or at least to not use it for TextStyle.height. In particular the upper DefaultTextStyle is only applying to three Text widgets that I see, plus to _ProfileDataTable which overrides most of it; and of those three, one is a button label, which seems like it shouldn't necessarily get the same styles as the information text anyway.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll remove it from the profile data table, in the refactor away from the Table widget I'll have the Row builders apply whatever style to the labels. I think it's still useful to have it on the upper part, although it only has three Text widgets currently, there will be more fields to be added later when the features backing them are introduced.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, that seems like a reasonable strategy. Though it looks like in the revision, you ended up doing away with the DefaultTextStyle entirely, in favor of having style constants (on a container class) and referring to those directly at Text widgets, and that approach looks good to me.

Comment on lines 248 to 256
return InkWell(
onTap: () => Navigator.push(context,
ProfilePage.buildRoute(context: context,
userId: userId)),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Avatar(userId: userId, size: 32, borderRadius: 32 / 8),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The InkWell needs some horizontal padding too ­— the highlighting looks odd at the left edge because it's flush with the avatar. (And if the user's name were just long enough to reach the right edge, I expect that'd look odd too.)

@sirpengi
Copy link
Contributor Author

sirpengi commented Sep 4, 2023

@gnprice Ready for the next round!

import 'page.dart';
import 'store.dart';

class _TextStyles {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've seen in other places usage of private _kFooBar variables used for reusable style following their 'Begin global constant names with prefix "k"' rule, but the same rule recommends using a container class over that. I do personally prefer this style, so I elected for refactor in this way for now.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, this approach looks good to me.

@sirpengi sirpengi force-pushed the pr-profile-screen branch 3 times, most recently from d92a018 to 66182bd Compare September 5, 2023 16:16
Comment on lines 72 to 73
test('ofUser: same user', () {
final actual = DmNarrow.withUser(5, selfUserId: 5);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: have test agree with tested code on name of constructor

Suggested change
test('ofUser: same user', () {
final actual = DmNarrow.withUser(5, selfUserId: 5);
test('withUser: same user', () {
final actual = DmNarrow.withUser(5, selfUserId: 5);

selfUserId: 5));
});

test('ofUser: user less than selfUserId', () {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
test('ofUser: user less than selfUserId', () {
test('ofUser: user ID less than selfUserId', () {

(plus the other change)

int? toJson() => apiValue;
}

/// The realm-level field data item for a "choice" custom profile field.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// The realm-level field data item for a "choice" custom profile field.
/// An item in the realm-level field data for a "choice" custom profile field.

Comment on lines 60 to 62
/// This is the decoding of values of [CustomProfileField.fieldData] when
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice].
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/// A list of these is the decoding of values of [CustomProfileField.fieldData] when
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice].

or that then sounds a bit awkward, so perhaps better:

Suggested change
/// This is the decoding of values of [CustomProfileField.fieldData] when
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice].
/// The value of [CustomProfileField.fieldData] decodes to a
/// `List<CustomProfileFieldChoiceDataItem>` when
/// the [CustomProfileField.type] is [CustomProfileFieldType.choice].

Comment on lines 86 to 88
required this.realmDefaultExternalAccounts,
required this.userSettings,
required this.maxFileUploadSizeMib,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump

Comment on lines 17 to 19
check(choices['0']?.text).equals('Option 0');
check(choices['1']?.text).equals('Option 1');
check(choices['2']?.text).equals('Option 2');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This checks these three elements are present but not that that's the whole map. Not a super high-risk thing to break, here, but it'd be nice to be complete.

One route is with deepEquals. That seems to end up a bit annoying with isA checks:

    check(choices).deepEquals({
      '0': it()..isA<CustomProfileFieldChoiceDataItem>().text.equals('Option 0'),
      '1': it()..isA<CustomProfileFieldChoiceDataItem>().text.equals('Option 1'),
      '2': it()..isA<CustomProfileFieldChoiceDataItem>().text.equals('Option 2'),
    });

though tolerable.

Another route would be deepEquals plus adding an == override.

Maybe the cleanest is our jsonEquals:

    check(choices).jsonEquals({
      '0': const CustomProfileFieldChoiceDataItem(text: 'Option 0'),
      '1': const CustomProfileFieldChoiceDataItem(text: 'Option 1'),
      '2': const CustomProfileFieldChoiceDataItem(text: 'Option 2'),
    });

Comment on lines 89 to 91
Icon(Icons.error),
Text('Could not show user profile.'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump #287 (comment)

It looks like some padding was added between the app bar and the error icon + text, which is probably good too. But the icon and the text look too close to each other.

import 'page.dart';
import 'store.dart';

class _TextStyles {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, this approach looks good to me.

import 'store.dart';

class _TextStyles {
static const profileText = TextStyle(fontSize: 20);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name is a bit unspecific — the whole page is the profile, so it doesn't help much in narrowing down which text this is for.

It seems like the answer is: currently name and role, and in the future perhaps email, user status, local time, etc. In other words the fields that are shown above the custom profile fields.

Perhaps headingText? Or primaryFieldText? Or could be profileHeadingText or profilePrimaryFieldText, though it's already private to the profile-page code so that doesn't seem essential.


Widget _buildTextWidget(String text) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, that seems like a reasonable strategy. Though it looks like in the revision, you ended up doing away with the DefaultTextStyle entirely, in favor of having style constants (on a container class) and referring to those directly at Text widgets, and that approach looks good to me.

@gnprice
Copy link
Member

gnprice commented Sep 6, 2023

Thanks for the revision! This is all generally looking good. Comments above — all pretty small — from looking back through my previous review and reading the related bits of code. Haven't yet looked at all the changes, but will do that next.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, and I've now finished reading the entire code in the PR, including the tests. Comments below — mostly in the tests, mostly small.

Comment on lines 55 to 57
onPressed: () => Navigator.push(context,
MessageListPage.buildRoute(context: context,
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

Suggested change
onPressed: () => Navigator.push(context,
MessageListPage.buildRoute(context: context,
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))),
onPressed: () => Navigator.push(context,
MessageListPage.buildRoute(context: context,
narrow: DmNarrow.withUser(userId, selfUserId: store.account.userId))),

appBar: AppBar(title: const Text('Error')),
body: const SingleChildScrollView(
child: Padding(
padding: EdgeInsets.fromLTRB(16, 32, 16, 16),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: probably cleanest if the padding is symmetric, no? That is, the same 32px padding below as above.

(Cleanest in the visual result if it ever matters, though that's unlikely; and cleanest to think about in the code in any case.)

Comment on lines 180 to 188
if (profileData == null) return const SizedBox.shrink();

List<Widget> items = [const SizedBox(height: 16)];

for (final realmField in store.customProfileFields) {
final profileField = profileData![realmField.id];
if (profileField == null) continue;
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField);
if (widget == null) continue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will end up rendering as blank or empty in one way if the user has no custom profile fields set (i.e. profileData == null), and in a different way if they do but none of them validate (they hit the widget == null continue here, or they don't match any field ID in the realm-wide store.customProfileFields).

Probably the SizedBox.shrink() is the better layout — the extra padding will likely look odd if there's nothing else there. So we can have a conditional for that after the loop if it turns out we didn't add any items.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also the user could have profileData be an empty Map rather than null, if they previously had a field and then we got an event to delete it. See PerAccountData.handleEvent.

We don't promise otherwise at the field definition:

  // null for bots, which don't have custom profile fields.
  // If null for a non-bot, equivalent to `{}` (null just written for efficiency.)
  @JsonKey(readValue: _readProfileData)
  Map<int, ProfileFieldUserData>? profileData;

instead only saying anything about what it means if it is null.

Possibly we should, though. It'd only be another line or two of code in handleEvent to ensure that, and the existence of both possible states is a bit of a pitfall.

(I think this build method's code wouldn't actually be any different if we made that change, though, since we have to go through the loop anyway to see if any of the fields validate.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone ahead and updated code in handleEvent for it.

Comment on lines 187 to 188
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField);
if (widget == null) continue;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Relatedly:

Suggested change
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField);
if (widget == null) continue;
final widget = _buildCustomProfileFieldValue(context, profileField.value, realmField);
if (widget == null) continue; // TODO(log)

This is the sort of condition that shouldn't happen, and that therefore in zulip-mobile we'd log to Sentry as a warning so that we can notice if it's getting hit in reality. We haven't yet worked out the framework for that in zulip-flutter, but these TODO(log) comments will be helpful for sweeping back through such places when we do.

Comment on lines 249 to 250
List<ZulipStream>? streams,
Map<String, RealmDefaultExternalAccount>? realmDefaultExternalAccounts,
UserSettings? userSettings,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the order of parameters here should match the order where they're passed to the constructor below (and both should match the order declared on the constructor, since this function is little more than a variation on the constructor with a bunch of defaults for the parameters)

await tester.tap(targetWidget, warnIfMissed: false);
check(pushedRoutes).last.isA<WidgetRoute>().page
.isA<MessageListPage>()
.narrow.equals(expectedNarrow);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: probably clearest if this just inlines expectedNarrow

});

testWidgets('page builds; ensure long values do not overflow', (WidgetTester tester) async {
final longString = List.filled(400, 'X').join();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
final longString = List.filled(400, 'X').join();
final longString = 'X' * 400;

Comment on lines 260 to 262
await setupPage(tester, users:[user], pageUserId: user.userId, customProfileFields: [
mkCustomProfileField(0, CustomProfileFieldType.shortText),
mkCustomProfileField(1, CustomProfileFieldType.user),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's cover all the field types here (except perhaps date which can't be long with realistic data).

Particularly key is to cover links, since that's a separate widget; but really it's just an extra two or three lines to cover each type, so might as well be complete.

Comment on lines 249 to 250
final findAvatars = find.byType(Avatar);
check(findAvatars.evaluate()).length.equals(2 + 1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This "2 + 1" feels a bit brittle to me; in particular the "+ 1" is implicitly referring to the fact that the top of the screen also shows an Avatar.

One approach is to make that more explicit by getting the specific user IDs and checking the list:

Suggested change
final findAvatars = find.byType(Avatar);
check(findAvatars.evaluate()).length.equals(2 + 1);
final avatars = tester.widgetList<Avatar>(find.byType(Avatar));
check(avatars.map((w) => w.userId).toList())
.deepEquals([1, 2, 3]);

If some later tweak makes the big image at the top not an Avatar widget, the test would still break; but it'd be a lot more evident how it broke and what the intent was, and so how to update the test.

Comment on lines 239 to 240
eg.user(userId: 2, fullName: 'test user1'),
eg.user(userId: 3, fullName: 'test user2'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: make the names match the user IDs — otherwise it may cause someone some confused debugging time in the future when fixing something that broke the test

@sirpengi
Copy link
Contributor Author

sirpengi commented Sep 6, 2023

@gnprice comments handled again!

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revision! Just a handful of comments now, and all of them small. Then we'll be ready to merge!

int? toJson() => apiValue;
}

/// An item in the realm-level field data item for a "choice" custom profile field.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// An item in the realm-level field data item for a "choice" custom profile field.
/// An item in the realm-level field data for a "choice" custom profile field.

Comment on lines 302 to 308
mkCustomProfileField(2, CustomProfileFieldType.choice,
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'),
mkCustomProfileField(3, CustomProfileFieldType.link),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
mkCustomProfileField(2, CustomProfileFieldType.choice,
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'),
mkCustomProfileField(3, CustomProfileFieldType.link),
mkCustomProfileField(2, CustomProfileFieldType.choice,
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'),
// no [CustomProfileFieldType.date] because those can't be made long
mkCustomProfileField(3, CustomProfileFieldType.link),

That way if the reader notices that the list is almost but not quite complete, they don't have to wonder whether the omission is just an oversight that should be fixed.

mkCustomProfileField(0, CustomProfileFieldType.shortText),
mkCustomProfileField(1, CustomProfileFieldType.longText),
mkCustomProfileField(2, CustomProfileFieldType.choice,
fieldData: '{"x": {"text": "${longString}", "order": "1"}}'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flutter analyze complains, saying unnecessary_brace_in_string_interps

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Weird, android studio doesn't flag this in the project analysis but running flutter analyze on the cli does flag it. I'll make sure to get better at checking these before pushing

Copy link
Member

@gnprice gnprice Sep 7, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm interesting. I haven't previously seen a divergence like that.

For me if I reintroduce the braces, Android Studio does flag it. Here's a screenshot (in an unnaturally small window to make the screenshot readable):
Screenshot from 2023-09-07 10-41-58

So it's there in the "Dart Analysis" pane at the bottom, and also in the list of issues at the top right of the editor pane (with the gray "!"-in-triangle icon — gray because it's an info-level issue). And the F2 key ("Next Highlighted Error") navigates to it.

Perhaps there's some Android Studio setting that affects that — like maybe that controls whether info-level issues are included? I don't remember changing such a setting, but it's possible I did long ago. If showing them isn't the default, it'd be good to put that setting in our .idea/ directory if we can, so that everyone working in our tree gets it automatically.

Comment on lines +278 to +280
if (profileData.isEmpty) {
// null is equivalent to `{}` for efficiency; see [User._readProfileData].
user.profileData = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: in commit message:

store: ensure empty map in profileData updates are converted to null

use sentence case for summary (i.e., s/ensure empty/Ensure empty/)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump

await tester.pumpAndSettle();
}

CustomProfileField mkCustomProfileField(int id, CustomProfileFieldType type, {int? order, bool? displayInProfileSummary, String? fieldData}) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thought!

@sirpengi
Copy link
Contributor Author

sirpengi commented Sep 7, 2023

@gnprice comments handled again, and I found an issue in the test that I've also fixed.

Change `CustomProfileField.type` into a proper enum,
add support for server event `custom_profile_fields`.
Added profile screen with user information and custom profile
fields, linked from sender name and avatar in message list.

User presence (zulip#196) and user status (zulip#197) are not
yet displayed or tracked here.

Fixes: zulip#195
Comment on lines 86 to +87
required this.userSettings,
required this.realmDefaultExternalAccounts,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tree isn't clean on rerunning dart run build_runner build, because the generated file has these in a different order.

@@ -41,6 +39,8 @@ class InitialSnapshot {
// TODO(server-5) remove pre-5.0 comment
final UserSettings? userSettings; // TODO(server-5)

final Map<String, RealmDefaultExternalAccount> realmDefaultExternalAccounts;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: spelling in commit message:

api: Add realmDefaultExtrenalAccounts

Comment on lines +278 to +280
if (profileData.isEmpty) {
// null is equivalent to `{}` for efficiency; see [User._readProfileData].
user.profileData = null;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bump

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Couple of small issues remaining, above.

These are all tiny enough that I'll just fix them up and merge. For the generated-files issue, the handy command for updating everything at the right spot in the branch is git rebase -x, as in git rebase -x 'dart run build_runner build'.

@gnprice gnprice merged commit c3df03e into zulip:main Sep 7, 2023
@sirpengi sirpengi deleted the pr-profile-screen branch January 23, 2024 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Profile screen for other users
2 participants